/*
Copyright (C) 2012 Brady Vidovic

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package xmltvdb.tvdb;

import xmltvdb.tools.Config;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jdom.*;
import xmltvdb.tools.CachedHTTP;
import static btv.tools.BTVTools.*;
import static xmltvdb.tools.Constants.*;

/**
 *
 * @author bvidovic
 */
public class TVDB
{
 
    
    public static final char[] UNCOMMON_CHAR_ARRAY = {'<', '>', ':', '"', '/', '\\', '|', '?', '*', 
        '#', '$', '%', '^', '*', '!', '~','\'', '’', '=', '[' ,']', '(', ')', ';', ',', '_'};
    public static Set<Character> UNCOMMON_CHARS = new HashSet<>();//diamond.. yay J7
    
    private List<TVDBSeries> serieses;
    private Episode e;        
    CachedHTTP HTTP;
    public TVDB(CachedHTTP http, Episode e) throws Exception
    {
        this.e = e;
        this.HTTP = http;
        
        if(e == null)
            throw new Exception("No episode found to look up.");

        if(!e.hasSeries())
            throw new Exception("No series name found.");

        //get the series from tvdb based on series name and language
        serieses = findMatchingSeries();//populates seriesIds
        if(serieses.isEmpty()) 
            throw new Exception("No series found on TVDB.com for series name \""+e.getSeries()+"\"");
                
        for(Character c : UNCOMMON_CHARS)
            UNCOMMON_CHARS.add(c);        
    }
    
    
    
    /*
         * ###if no series, no lookup###
         * 
         * Lookups from best to worst:
         ---------------------------------------
         * exact title & original airdate (check for multipart?)
         * fuzzy title & original air date (check for multipart)
         * contains title & original air date (check for multipart)
         * exact title (must be a solo match)
         * original airdate (must be a solo match)
         * fuzzy title (must be a solo match)
         * contains title (must be a solo match)
         * 
         * checking for multiparts:
         *  multipart means that a single airing in the guide XML maps to 2 or more seperate entries on thetvdb.com
         *      The original air date for all tvdb entries must be the same
         *      The titles must either match fuzzy or match contains.
         */
    public boolean lookup()
    {                
                        
        List<Match> matches = new ArrayList<>();
        for(TVDBSeries series : serieses)  
        {
            //String seriesId = entry.getKey();
            TVDBMatcher tvdbMatch = getMatches(series);
                   
            if(tvdbMatch == null) continue;
            
            Logger.INFO("TVDB Matches Found (title="+e.getTitle()+", airdate="+(e.hasOriginalAirDate() ? tvdbsdf.format(e.getOriginalAirDate()) : "unknown")+"):"+LINE_BRK+
                    "originalAirDateMatches: "+tvdbMatch.originalAirDateMatches+LINE_BRK+
                    "exactTitleMatches:      "+tvdbMatch.exactTitleMatches+LINE_BRK+
                    "fuzzyTitleMatches:      "+tvdbMatch.fuzzyTitleMatches+LINE_BRK+
                    "containsTitleMatches:   "+tvdbMatch.containsTitleMatches);
            
            //check air date + title matches
            if(!tvdbMatch.originalAirDateMatches.isEmpty())//original airdate had some some matches
            {
                //exact title & date
                if(!tvdbMatch.exactTitleMatches.isEmpty())
                {
                    
                    if(tvdbMatch.originalAirDateMatches.containsAll(tvdbMatch.exactTitleMatches))
                    {
                        matches.add(new Match(MatchAccuracy.ORIGINAL_AIR_DATE_AND_EXACT_TITLE, series, tvdbMatch.exactTitleMatches));
                        break;//exact match, we can break now
                    }
                }

                //fuzzytitle & date            
                if(tvdbMatch.fuzzyTitleMatches.containsAll(tvdbMatch.originalAirDateMatches))
                {
                    matches.add(new Match(MatchAccuracy.ORIGINAL_AIR_DATE_AND_FUZZY_TITLE, series, tvdbMatch.originalAirDateMatches));
                    break;//exact (enough) match, we can break now
                }

                //contains title & date                            
                if(tvdbMatch.containsTitleMatches.containsAll(tvdbMatch.originalAirDateMatches))
                {                                        
                    matches.add(new Match(MatchAccuracy.ORIGINAL_AIR_DATE_AND_CONTAINS_TITLE, series, tvdbMatch.originalAirDateMatches));
                    continue;//check next series too since this isn't a very exact match
                }
                else//check in opposite direction. Catches if multiple episodes have same original air date, but needs to be filtered down to this episode
                {
                    if(!tvdbMatch.containsTitleMatches.isEmpty())
                    {
                        if(tvdbMatch.originalAirDateMatches.containsAll(tvdbMatch.containsTitleMatches))
                        {
                            matches.add(new Match(MatchAccuracy.ORIGINAL_AIR_DATE_AND_CONTAINS_TITLE, series, tvdbMatch.containsTitleMatches));                            
                            continue;//check next series too since this isn't very exact match
                        }
                    }
                }
            }//done with date combo matches
            
            
            //if we got here, there are no air date/combo matches. Check for title matches, but don't allow multi
            //part for those because we can't verify they aired on the same date.
            
            //exact title, solo match                    
            if(tvdbMatch.exactTitleMatches.size() == 1)
            {
                Logger.INFO("Found Exact title match: "+ tvdbMatch.exactTitleMatches);
                matches.add(new Match(MatchAccuracy.EXACT_TITLE, series, tvdbMatch.exactTitleMatches));
                break;//exact enough, break here
            }
            else if(tvdbMatch.exactTitleMatches.size() > 1)
            {
                Logger.WARN("Found "+ tvdbMatch.exactTitleMatches.size()+" exact title matches! Don't know what one to use!");
            }
            
            //original airdate, solo/multi match            
            if(!tvdbMatch.originalAirDateMatches.isEmpty())
            {
                //allow multi match for original air date
                matches.add(new Match(MatchAccuracy.ORIGINAL_AIR_DATE, series, tvdbMatch.originalAirDateMatches));                
                continue;
            }
            
            //fuzzy title, solo match                            
            if(tvdbMatch.fuzzyTitleMatches.size() == 1)
            {
                matches.add(new Match(MatchAccuracy.FUZZY_TITLE, series, tvdbMatch.fuzzyTitleMatches));
                continue;
            }
            else if(tvdbMatch.fuzzyTitleMatches.size() > 1)
                Logger.INFO("Found "+ tvdbMatch.fuzzyTitleMatches.size()+" fuzzy title matches! Don't know what one to use!");
            
            //contains title, solo match            
            if(tvdbMatch.containsTitleMatches.size() == 1)
            {
                matches.add(new Match(MatchAccuracy.CONTAINS_TITLE, series, tvdbMatch.containsTitleMatches));                
                continue;
            }
            
            
            //allow multi-contains if the air date is all the same
            if(tvdbMatch.containsTitleMatches.size() > 1)
            {
                Logger.INFO("Found multiple contains-title matches, determining if all have same original air date.");
                String date= null;
                for(TVDBEpisode ep : tvdbMatch.containsTitleMatches)
                {
                    if(!valid(ep.firstAired))
                    {//no valid date from tvdb... can't check this
                        date = null;
                        break;
                    }
                    
                    if(date == null) date = ep.firstAired;//init
                    else if(!date.equals(ep.firstAired))
                    {
                        date = null;//signals invalid because they dont all have the same date
                        break;
                    }
                }
                if(date != null)//same date for all episodes
                {
                    Logger.INFO("All contains-title matches have same original air date, will add as a multi-part episode");
                    matches.add(new Match(MatchAccuracy.CONTAINS_TITLE, series, tvdbMatch.containsTitleMatches));  
                }
                else
                    Logger.INFO("Contains-title matches have different air-dates, cannot determine what episode to use.");
            }
        }//end looping through all series and finding matches
        
        if(!matches.isEmpty())
        {
            Logger.INFO("Found "+ matches.size() +" TVDB.com match" + (matches.size()==1 ? ", will use it" :"es, will use best one"));
            Collections.sort(matches);//best matches (most accurate) are on top now
            
             //print all matches
            for(Match m : matches)
            {
                Logger.NOTICE( "Match - Series: "+ m.series+": "+ m.accuracy +" for "+ m.matchingEpisodes.size() +" episode(s):"+LINE_BRK+m.matchingEpisodes);                                
            }
            
            //use the first (best) match
            Match bestMatch = matches.get(0);
            e.setSeries(bestMatch.series);
            if(bestMatch.matchingEpisodes.size() > 1)//log if multiple episodes
                Logger.INFO("Multi-Part: Setting matching episodes to: "+ bestMatch.matchingEpisodes);
            
            e.setMatchingEpisodes(bestMatch.matchingEpisodes);                       
            return true;
        }
        else
        {            
            if(e.hasRemoteId())//maybe the remote id is wrong, try it based on series name
            {
                Logger.NOTICE( "No match was found using exernal id(s) ("+e.getRemoteIds().values()+"), will try lookup based on series name now.");
                e.clearRemoteIds();                
                serieses = findMatchingSeries();
                return lookup();//recurse using the new seriesIds
            }
            else//no match
            {
                Logger.WARN( "Found no matches!");
                return false;
            }
        }
    }
    
    private TVDBMatcher getMatches(TVDBSeries series)
    {               
            Logger.DEBUG( "Getting episodes for series: "+ series.seriesName);

            //look for matches on title & original air date
            String tvdbURL = "http://www.thetvdb.com/api/" + TVDB_API_KEY + "/series/"+series.seriesId+"/all/"+e.getLanguage()+".zip";
            //Logger.log(tvdbURL);

            Document xml = HTTP.getXMLFromURL(tvdbURL,e.getLanguage()+".xml");
            if(xml == null) return null;
            Element seriesElem = xml.getRootElement().getChild("Series");
            /*Sample series:
               <Series>
                  <id>248835</id>
                  <Actors>|Jennifer Morrison|Josh Dallas|Ginnifer Goodwin|Lana Parrilla|Archie Hopper|Jared Gilmore|Jamie Dornan|Robert Carlyle|</Actors>
                  <Airs_DayOfWeek>Sunday</Airs_DayOfWeek>
                  <Airs_Time>8:00 PM</Airs_Time>
                  <ContentRating>TV-PG</ContentRating>
                  <FirstAired>2011-10-23</FirstAired>
                  <Genre>|Drama|Fantasy|</Genre>
                  <IMDB_ID>tt1843230</IMDB_ID>
                  <Language>en</Language>
                  <Network>ABC</Network>
                  <NetworkID></NetworkID>
                  <Overview>Once Upon a Time revolves around Emma Swan, a 28-year-old bail bonds collector that has been supporting herself from when she was abandoned as a baby. Things change for her when her son Henry, who she gave up for adoption years ago, finds her and tells that he needs her help. He tells that she is really from a different world where she is Snow White's missing daughter. He shows that in the fairytale books, Prince Charming and Snow White sent her away to protect her. The Evil Queen cast a spell that trapped the fairytale world. Emma doesn't believe him and takes Henry back to Storybrooke....</Overview>
                  <Rating>8.2</Rating>
                  <RatingCount>31</RatingCount>
                  <Runtime>60</Runtime>
                  <SeriesID>80922</SeriesID>
                  <SeriesName>Once Upon a Time (2011)</SeriesName>
                  <Status>Continuing</Status>
                  <added>2011-05-18 00:14:25</added>
                  <addedBy>189361</addedBy>
                  <banner>graphical/248835-g11.jpg</banner>
                  <fanart>fanart/original/248835-6.jpg</fanart>
                  <lastupdated>1328897875</lastupdated>
                  <poster>posters/248835-1.jpg</poster>
                  <zap2it_id>EP01419478</zap2it_id>
                </Series>
             */
            series.seriesName = seriesElem.getChildText("SeriesName");
            
            //parse the year this series was first aired
            String firstAired = seriesElem.getChildText("FirstAired");//2011-10-23
            if(valid(firstAired))
            {
                try{
                    String[] parts = firstAired.split("-");
                    if(parts.length != 3)
                        throw new Exception("Unkown aired date, expected yyyy-mm-dd, found: "+ firstAired);

                    String year = parts[0];                                
                    if(year.length() != 4) 
                        throw new Exception("Unknown year: "+ year);

                    series.seriesYear = year; 
                }catch(Exception x)
                {
                    Logger.WARN( "Could not parse year from date: "+ firstAired);
                }
            }
            
            List <Element> episodes = xml.getRootElement().getChildren("Episode");                
            if(episodes == null || episodes.isEmpty())
            {
                Logger.WARN( "No episodes found on TheTVDB for series: "+series+". "
                    + "You may need to add the series/episodes on TheTVDB.com, or manually provide the correct TVDB id in the config file.");
                return null;
            }
            else
                Logger.DEBUG( "Will search "+ episodes.size() +" episodes for a match.");
            
            Logger.INFO("Found "+episodes.size()+" episodes for series: "+ series);
            TVDBMatcher matches = new TVDBMatcher(e, episodes);
            return matches;                            
    }
    
    private List<TVDBSeries> findMatchingSeries()
    {        
        List<TVDBSeries> ids = new ArrayList<>();
        if(e.hasTVDBid())
        {
            ids.add(new TVDBSeries(e.getTVDBId()));//we alrady know the series id
            Logger.INFO( "TheTVDB series ID is overridden in config file, no need to look it up. Using ID of: \"" +e.getTVDBId() +"\"");
            return ids;
        }
        
        //remote id provided from schedulesdirect/zap2it or imdb
        if(e.hasRemoteId())
        {
            //GetSeriesByRemoteID.php?imdbid=<imdbid>&language=<language>&zap2it=<zap2it>     
            for(Map.Entry<String,String> entry : e.getRemoteIds().entrySet())
            {
                String remoteProvider = entry.getKey();
                String remoteId = entry.getValue();
                
                String tvdbURL = "http://www.thetvdb.com/api/GetSeriesByRemoteID.php?"+remoteProvider+"="+URLEncode(remoteId)+"&language="+e.getLanguage();
                /**Sample return
                 * <?xml version="1.0" encoding="UTF-8" ?>
                    <Data>
                     <Series>
                      <seriesid>78107</seriesid>
                      <language>en</language>
                      <SeriesName>The Office (UK)</SeriesName>
                      <banner>graphical/78107-g9.jpg</banner>
                      <Overview>A mockumentary about life in a mid-sized suboffice paper merchants in a bleak British indus....</Overview>
                      <FirstAired>2001-07-01</FirstAired>
                      <IMDB_ID>tt0290978</IMDB_ID>
                      <id>78107</id>
                     </Series>
                    </Data>
                 */
                Document xml = HTTP.getXMLFromURL(tvdbURL);
                if(xml == null) continue;
                Element dataElem = xml.getRootElement();
                List<Element> children = dataElem.getChildren();
                if(children.isEmpty())
                {
                    Logger.WARN( "No tvdb series id found for "+remoteProvider+" \""+remoteId+"\"" );
                    //will do a search
                    
                }
                else//only returns 1 series                
                {
                    Element series = children.get(0);                    
                    String seriesId = series.getChildText("seriesid");
                    ids.add(new TVDBSeries(seriesId));
                    String seriesName =  series.getChildText("SeriesName");
                    Logger.INFO( "Found TVDB series using "+remoteProvider+" \""+remoteId+"\": seriesName= \"" + seriesName + "\" tvdbid = \"" + seriesId+"\"");
                    return ids;
                }                
            }
        }
        
        if(e.hasRemoteId())
        {//if got here, we did not successfully lookup based on remote id. Remove it since it's not useful to us
            e.clearRemoteIds();
        }
        
        //if got here no id specified or remote id lookup failed, lookup based on series name
        int maxSeries = Config.MAX_TVDB_SERIES_LOOKUP;            
        String seriesName = e.getSeries().seriesName;
        final String tvdbURL = "http://www.thetvdb.com/api/GetSeries.php?seriesname="+URLEncode(seriesName)+"&language="+e.getLanguage();
        Logger.INFO( "Attempting to get series ID (max of "+maxSeries+") based on seriesname of '"+seriesName+"', url = " + tvdbURL);

        Document xml = HTTP.getXMLFromURL(tvdbURL);
        if(xml != null)
        {
            List<Element> children = xml.getRootElement().getChildren();
            int seriesCount = 0;
            for(Element series : children)
            {
                
                String seriesId = series.getChildText("seriesid");
                seriesName =  series.getChildText("SeriesName");
                TVDBSeries nextSeries = new TVDBSeries(seriesId, seriesName, null);
                if(ids.contains(nextSeries)) continue;//dont want dups
                
                ids.add(nextSeries);                
                seriesCount++;
                Logger.INFO( "Adding Series #"+seriesCount+" found from thetvdb: seriesName= \"" + seriesName + "\" tvdbid = \"" + seriesId+"\"");

                if(seriesCount == maxSeries) break;//limit
            }
        }

        if(ids.isEmpty())            
             Logger.WARN( "No series could be found by querying TheTVDB.com for series named \""+e.getSeries()+"\"");

        return ids;
               
    }
    
    //Helper Methods
    public final static SimpleDateFormat tvdbsdf = new SimpleDateFormat("yyyy-MM-dd");
    public static String dateToTVDBString(Date d)
    {
        if(d == null) return null;
        return tvdbsdf.format(d);
    }
    
    public static String normalize(String s)
    {
        if(s == null) return "";
        String normal = "";
        for(int i=0;i<s.length();i++)
        {            
            char c = s.charAt(i);
            if(c == '/') c = ' ';//replace slash with a space
            else if(!UNCOMMON_CHARS.contains(c))//noy uncommon
                normal += c;
        }
       return normal;
    }

     public static String cleanCommonWords(String s)
    {
        if(!valid(s)) return s;
        String[] articles = new String[]{"the","a", "an", "The", "A", "An", "part", "Part"};
        for(String article : articles)
        {
            // it is a word if it has a space around it
            s = s.replace(" "+article+" ", " ");
            s = s.replace(" " +article, " ");
            s = s.replace(article+" ", " ");
            s = s.replace("  ", " ");//catch any double spaces
        }
        return s;
    }

    public static String cleanParenthesis(String s)
    {
        Pattern p = Pattern.compile("[\\(].*[\\)]");///catch any characters inside of (...)
        Matcher m = p.matcher(s);
        while(m.find())
        {
            s = s.replace(m.group(), "");
        }
        return s;
    }

    public static boolean fuzzyTitleMatch(String source, String test, int percentDiscrepencyAllowed)
    {
        if(!valid(source) || !valid(test)) return false;
        //clean out articles and anything in parenthesis
        source = cleanCommonWords(cleanParenthesis(source.toLowerCase()));
        test = cleanCommonWords(cleanParenthesis(test.toLowerCase()));
        if(percentDiscrepencyAllowed > 100) percentDiscrepencyAllowed = 100;
        int fuzzyMatchMaxDifferent = source.length() / (100/percentDiscrepencyAllowed);//allow x% discrepency
        if(fuzzyMatchMaxDifferent <= 0) fuzzyMatchMaxDifferent = 1;//allow at least 1 char diff
        int difference = getLevenshteinDistance(source, test);
        return difference <= fuzzyMatchMaxDifferent;
    }

    public static int getLevenshteinDistance(String s, String t) {
      if (s == null || t == null) {
          throw new IllegalArgumentException("Strings must not be null");
      }
      
      int n = s.length(); // length of s
      int m = t.length(); // length of t

      if (n == 0) {
          return m;
      } else if (m == 0) {
          return n;
      }

      if (n > m) {
          // swap the input strings to consume less memory
          String tmp = s;
          s = t;
          t = tmp;
          n = m;
          m = t.length();
      }

      int p[] = new int[n+1]; //'previous' cost array, horizontally
      int d[] = new int[n+1]; // cost array, horizontally
      int _d[]; //placeholder to assist in swapping p and d

      // indexes into strings s and t
      int i; // iterates through s
      int j; // iterates through t

      char t_j; // jth character of t

      int cost; // cost

      for (i = 0; i<=n; i++) {
          p[i] = i;
      }

      for (j = 1; j<=m; j++) {
          t_j = t.charAt(j-1);
          d[0] = j;

          for (i=1; i<=n; i++) {
              cost = s.charAt(i-1)==t_j ? 0 : 1;
              // minimum of cell to the left+1, to the top+1, diagonally left and up +cost
              d[i] = Math.min(Math.min(d[i-1]+1, p[i]+1),  p[i-1]+cost);
          }

          // copy current distance counts to 'previous row' distance counts
          _d = p;
          p = d;
          d = _d;
      }

      // our last action in the above loop was to switch d and p, so p now
      // actually has the most recent cost counts
      return p[n];
  }

    private class TVDBMatcher
    {
        List<TVDBEpisode> originalAirDateMatches = new ArrayList<>();
        
        List<TVDBEpisode> exactTitleMatches = new ArrayList<>();
        List<TVDBEpisode> fuzzyTitleMatches = new ArrayList<>();
        List<TVDBEpisode> containsTitleMatches = new ArrayList<>();
        
        public TVDBMatcher(Episode guideEpisode, List <Element> tvdbEpisodes)
        {
            /*
             * exact title & original airdate (check for multipart?)
             * fuzzy title & original air date (check for multipart)
             * contains title & original air date (check for multipart)
             * exact title (must be a solo match)
             * original airdate (must be a solo match)
             * fuzzy title (must be a solo match)
             * contains title (must be a solo match)
             */
            
            //episode info
            String guideTitle = guideEpisode.getTitle();
            boolean hasTitle = guideEpisode.hasTitle();
            
            String epFirstAired = guideEpisode.hasOriginalAirDate() ? dateToTVDBString(guideEpisode.getOriginalAirDate()) : null;
            boolean hasOriginalAirDate = epFirstAired != null;
                
            if(!hasTitle && !hasOriginalAirDate) return;//nothing to do!
            
            for(Element tvdbEp : tvdbEpisodes)
            {                                
                //tvdb info
                String tvdbTitle = tvdbEp.getChildText("EpisodeName");
                String tvdbFirstAired = tvdbEp.getChildText("FirstAired");                
                String tvdbSeasonNumber = tvdbEp.getChildText("SeasonNumber");
                String tvdbEpisodeNumber = tvdbEp.getChildText("EpisodeNumber");
                boolean hasTVDBImage = valid(tvdbEp.getChildText("filename"));//<filename> stores the path to the .jpg image for the episode
                final TVDBEpisode tvdbEpisode = new TVDBEpisode(tvdbTitle, tvdbFirstAired, tvdbSeasonNumber, tvdbEpisodeNumber, hasTVDBImage);
                
                if(hasTitle && valid(tvdbTitle))
                {
                    //exact title matches (alwasy case-insensitive and special chars removed (normalized))
                    if(normalize(guideTitle).equalsIgnoreCase(normalize(tvdbTitle)))
                    {
                        exactTitleMatches.add(tvdbEpisode);
                    }                
                    else //not exact title match, check for fuzzy and contains
                    {
                        if(fuzzyTitleMatch(guideTitle, tvdbTitle, 15))//allow 15 percent discrepency)
                            fuzzyTitleMatches.add(tvdbEpisode);

                        //check for contains (the program guide title contains the tvdb title)                                                
                        boolean contains = guideTitle.toLowerCase().contains(tvdbTitle.toLowerCase());
                        boolean containsNormalized =  normalize(guideTitle).toLowerCase().contains(normalize(tvdbTitle).toLowerCase());
                        if(contains || containsNormalized)
                            containsTitleMatches.add(tvdbEpisode);                                                                                    
                    }
                }
                
                if(hasOriginalAirDate)
                {
                    if(epFirstAired.equalsIgnoreCase(tvdbFirstAired))
                        originalAirDateMatches.add(tvdbEpisode);
                }                                                                                
            }
        }
    }
    
    

    private class Match implements Comparable<Match>
    {
        public MatchAccuracy accuracy;
        public List<TVDBEpisode> matchingEpisodes;
        public TVDBSeries series;
        public Match(MatchAccuracy matchAccuracy, TVDBSeries series, List<TVDBEpisode> matchingEpisodes)
        {
            this.accuracy = matchAccuracy;
            this.series = series;
            this.matchingEpisodes = matchingEpisodes;
        }                

        @Override
        public int compareTo(Match o) {//more accurate on top
            return this.accuracy.compareTo(accuracy);                        
        }
        
    }
}